/*
 * Copyright (C) 2011 Alastair R. Beresford
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 * in compliance with the License. You may obtain a copy of the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */
package com.google.nigori.server;

import static com.google.nigori.common.MessageLibrary.toBytes;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.HashMap;
import java.util.logging.Logger;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.codec.binary.Base64;

import com.google.nigori.common.MessageLibrary;
import com.google.nigori.common.MessageLibrary.JsonConversionException;
import com.google.nigori.common.NigoriMessages.AuthenticateRequest;
import com.google.nigori.common.NigoriMessages.DeleteRequest;
import com.google.nigori.common.NigoriMessages.GetIndicesRequest;
import com.google.nigori.common.NigoriMessages.GetRequest;
import com.google.nigori.common.NigoriMessages.GetResponse;
import com.google.nigori.common.NigoriMessages.GetRevisionsRequest;
import com.google.nigori.common.NigoriMessages.PutRequest;
import com.google.nigori.common.NigoriMessages.RegisterRequest;
import com.google.nigori.common.NigoriMessages.UnregisterRequest;
import com.google.nigori.common.NigoriProtocol;
import com.google.nigori.common.NotFoundException;
import com.google.nigori.common.UnauthorisedException;
import com.google.nigori.server.appengine.AppEngineDatabase;

public class NigoriServlet extends HttpServlet {

  private static final long serialVersionUID = 1L;
  private static final boolean DEBUG_JSON = false;
  private static final Logger log = Logger.getLogger(NigoriServlet.class.getName());
  private static final int maxJsonQueryLength = 1024 * 1024 * 1;
  private final NigoriProtocol protocol;

  public NigoriServlet() {
    this(new AppEngineDatabase());
  }

  public NigoriServlet(Database database) {
    super();
    this.protocol = new DatabaseNigoriProtocol(database);
  }

  private class ServletException extends Exception {
    private static final long serialVersionUID = 1L;
    private int statusCode;

    ServletException(int statusCode, String message) {
      super(message);
      this.statusCode = statusCode;
    }

    int getStatusCode() {
      return statusCode;
    }

    void writeHttpResponse(HttpServletResponse resp) throws IOException {
      resp.setContentType(MessageLibrary.MIMETYPE_JSON);
      resp.setCharacterEncoding(MessageLibrary.CHARSET);
      resp.setStatus(getStatusCode());
      resp.getOutputStream().write(toBytes(this.getMessage()));
    }
  }

  private String getJsonAsString(HttpServletRequest req, int maxLength) throws ServletException {

    if (maxLength != 0 && req.getContentLength() > maxLength) {
      return null;
    }

    String charsetName = req.getCharacterEncoding();
    if (charsetName == null) {
      charsetName = MessageLibrary.CHARSET;
    }

    try {
      BufferedReader in =
          new BufferedReader(new InputStreamReader(req.getInputStream(), charsetName));
      StringBuilder json = new StringBuilder();
      char[] buffer = new char[64 * 1024];
      int charsRemaining = maxJsonQueryLength;
      int charsRead;
      while ((charsRead = in.read(buffer)) != -1) {
        charsRemaining -= charsRead;
        if (charsRemaining < 0) {
          throw new ServletException(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE,
              "Json request exceeds server maximum length of " + maxLength);
        }
        json.append(buffer, 0, charsRead);
      }
      return json.toString();
    } catch (IOException ioe) {
      throw new ServletException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
          "Internal error receiving data from client.");
    }
  }

  /**
   * Class to support efficient lookup of appropriate handler method for a particular request.
   */
  private class RequestHandlerType {

    private String mimetype;
    private String requestType;

    RequestHandlerType(String mimetype, String requestType) {
      this.mimetype = mimetype;
      this.requestType = requestType;
    }

    @Override
    public int hashCode() {
      return mimetype.hashCode() + requestType.hashCode();
    }

    @Override
    public boolean equals(Object obj) {
      if (obj instanceof RequestHandlerType) {
        RequestHandlerType r = (RequestHandlerType) obj;
        return this.hashCode() == r.hashCode();
      }
      return false;
    }

    @Override
    public String toString() {
      return "<" + mimetype + "," + requestType + ">";
    }
  }

  /**
   * Send an SC_OK and and empty body
   * 
   * @param resp
   * @throws ServletException
   */
  private void emptyBody(HttpServletResponse resp) throws ServletException {
    try {
      resp.setContentType(MessageLibrary.MIMETYPE_JSON);
      resp.setStatus(HttpServletResponse.SC_OK);
      resp.flushBuffer();
    } catch (IOException ioe) {
      throw new ServletException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
          "Error attempting to write status OK message after successfully handling a PutRequest");
    }
  }

  private abstract interface RequestHandler {
    public void handle(HttpServletRequest req, HttpServletResponse resp) throws ServletException,
        IOException, JsonConversionException, UnauthorisedException, NotFoundException;
  }

  private class JsonGetRequestHandler implements RequestHandler {

    @Override
    public void handle(HttpServletRequest req, HttpServletResponse resp) throws ServletException,
        IOException, JsonConversionException, NotFoundException, UnauthorisedException {

      String json = getJsonAsString(req, maxJsonQueryLength);
      GetRequest request = MessageLibrary.getRequestFromJson(json);
      GetResponse response = protocol.get(request);

      String jsonresponse = MessageLibrary.toJson(response);
      resp.setContentType(MessageLibrary.MIMETYPE_JSON);
      resp.setCharacterEncoding(MessageLibrary.CHARSET);
      resp.setStatus(HttpServletResponse.SC_OK);
      BufferedWriter w = new BufferedWriter(new OutputStreamWriter(resp.getOutputStream()));
      w.write(jsonresponse);
      w.flush();
    }
  }

  private class JsonGetIndicesRequestHandler implements RequestHandler {

    @Override
    public void handle(HttpServletRequest req, HttpServletResponse resp) throws ServletException,
        IOException, NotFoundException, UnauthorisedException, JsonConversionException {

      String json = getJsonAsString(req, maxJsonQueryLength);

      GetIndicesRequest request = MessageLibrary.getIndicesRequestFromJson(json);

      String response = MessageLibrary.toJson(protocol.getIndices(request));
      resp.setContentType(MessageLibrary.MIMETYPE_JSON);
      resp.setCharacterEncoding(MessageLibrary.CHARSET);
      resp.setStatus(HttpServletResponse.SC_OK);
      BufferedWriter w = new BufferedWriter(new OutputStreamWriter(resp.getOutputStream()));
      w.write(response);
      w.flush();
    }
  }

  private class JsonGetRevisionsRequestHandler implements RequestHandler {

    @Override
    public void handle(HttpServletRequest req, HttpServletResponse resp) throws ServletException,
        IOException, NotFoundException, UnauthorisedException, JsonConversionException {

      String json = getJsonAsString(req, maxJsonQueryLength);

      GetRevisionsRequest request = MessageLibrary.getRevisionsRequestFromJson(json);

      String response = MessageLibrary.toJson(protocol.getRevisions(request));
      resp.setContentType(MessageLibrary.MIMETYPE_JSON);
      resp.setCharacterEncoding(MessageLibrary.CHARSET);
      resp.setStatus(HttpServletResponse.SC_OK);
      BufferedWriter w = new BufferedWriter(new OutputStreamWriter(resp.getOutputStream()));
      w.write(response);
      w.flush();
    }
  }

  private class JsonPutRequestHandler implements RequestHandler {

    @Override
    public void handle(HttpServletRequest req, HttpServletResponse resp) throws ServletException,
        JsonConversionException, IOException, UnauthorisedException {
      String json = getJsonAsString(req, maxJsonQueryLength);
      if (DEBUG_JSON) {
        System.out.println(json);
      }
      PutRequest request = MessageLibrary.putRequestFromJson(json);

      if (!protocol.put(request)) {
        throw new ServletException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
            "Internal storage error for key "
                + Base64.encodeBase64String(request.getKey().toByteArray()));
      }

      emptyBody(resp);
    }
  }

  private class JsonDeleteRequestHandler implements RequestHandler {

    @Override
    public void handle(HttpServletRequest req, HttpServletResponse resp) throws ServletException,
        JsonConversionException, IOException, UnauthorisedException, NotFoundException {
      String json = getJsonAsString(req, maxJsonQueryLength);

      if (DEBUG_JSON) {
        System.out.println(json);
      }
      DeleteRequest request = MessageLibrary.deleteRequestFromJson(json);

      if (!protocol.delete(request)) {
        throw new ServletException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
            "Internal storage error for key "
                + Base64.encodeBase64String(request.getKey().toByteArray()));
      }

      emptyBody(resp);
    }
  }

  private class JsonAuthenticateRequestHandler implements RequestHandler {

    @Override
    public void handle(HttpServletRequest req, HttpServletResponse resp) throws ServletException,
        JsonConversionException, IOException, UnauthorisedException {
      String json = getJsonAsString(req, maxJsonQueryLength);
      AuthenticateRequest auth = MessageLibrary.authenticateRequestFromJson(json);
      boolean success = protocol.authenticate(auth);
      if (!success) {
        throw new UnauthorisedException("Authorisation failed");
      }

      emptyBody(resp);
    }
  }

  private class JsonRegisterRequestHandler implements RequestHandler {

    @Override
    public void handle(HttpServletRequest req, HttpServletResponse resp) throws ServletException,
        JsonConversionException, IOException {

      String json = getJsonAsString(req, maxJsonQueryLength);
      RegisterRequest request = MessageLibrary.registerRequestFromJson(json);

      boolean success = protocol.register(request);
      if (!success) {
        throw new ServletException(HttpServletResponse.SC_CONFLICT, "Adding user "
            + Base64.encodeBase64String(request.getPublicKey().toByteArray())
            + " failed, may already exist");
      }
      emptyBody(resp);
    }
  }

  private class JsonUnregisterRequestHandler implements RequestHandler {

    @Override
    public void handle(HttpServletRequest req, HttpServletResponse resp) throws ServletException,
        IOException, UnauthorisedException, JsonConversionException {
      String json = getJsonAsString(req, maxJsonQueryLength);
      UnregisterRequest request = MessageLibrary.unregisterRequestFromJson(json);

      boolean success = protocol.unregister(request);
      if (!success) {
        throw new ServletException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Removing user "
            + Base64.encodeBase64String(request.getAuth().getPublicKey().toByteArray()) + " failed");
      }
      emptyBody(resp);
    }
  }

  // TODO(beresford): double-check that Servlet instances are created rarely
  private String supportedTypes = null;
  private HashMap<RequestHandlerType, RequestHandler> handlers = initHandlers();

  private HashMap<RequestHandlerType, RequestHandler> initHandlers() {
    HashMap<RequestHandlerType, RequestHandler> h =
        new HashMap<RequestHandlerType, RequestHandler>();
    h.put(new RequestHandlerType(MessageLibrary.MIMETYPE_JSON, MessageLibrary.REQUEST_GET),
        new JsonGetRequestHandler());
    h.put(new RequestHandlerType(MessageLibrary.MIMETYPE_JSON, MessageLibrary.REQUEST_GET_INDICES),
        new JsonGetIndicesRequestHandler());
    h.put(
        new RequestHandlerType(MessageLibrary.MIMETYPE_JSON, MessageLibrary.REQUEST_GET_REVISIONS),
        new JsonGetRevisionsRequestHandler());
    h.put(new RequestHandlerType(MessageLibrary.MIMETYPE_JSON, MessageLibrary.REQUEST_PUT),
        new JsonPutRequestHandler());
    h.put(new RequestHandlerType(MessageLibrary.MIMETYPE_JSON, MessageLibrary.REQUEST_DELETE),
        new JsonDeleteRequestHandler());
    h.put(
        new RequestHandlerType(MessageLibrary.MIMETYPE_JSON, MessageLibrary.REQUEST_AUTHENTICATE),
        new JsonAuthenticateRequestHandler());
    h.put(new RequestHandlerType(MessageLibrary.MIMETYPE_JSON, MessageLibrary.REQUEST_REGISTER),
        new JsonRegisterRequestHandler());
    h.put(new RequestHandlerType(MessageLibrary.MIMETYPE_JSON, MessageLibrary.REQUEST_UNREGISTER),
        new JsonUnregisterRequestHandler());
    StringBuilder supportedPairs =
        new StringBuilder("The following mimetypes and request pairs are supported: ");
    for (RequestHandlerType type : h.keySet()) {
      supportedPairs.append("(" + type.mimetype + " - " + type.requestType + ") ");
    }
    supportedTypes = supportedPairs.toString();
    return h;
  }

  /**
   * Enable cors: http://enable-cors.org/server.html to allow access from javascript/dart clients
   * using code from a different domain
   * 
   * @param resp the response to add the headers to
   */
  private void addCorsHeaders(HttpServletResponse resp) {
    resp.addHeader("Access-Control-Allow-Origin", "*");
    resp.addHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
    resp.addHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
  }

  /**
   * Add CORS headers to options requests
   */
  @Override
  protected void doOptions(HttpServletRequest req, HttpServletResponse resp) throws IOException,
      javax.servlet.ServletException {
    addCorsHeaders(resp);
    super.doOptions(req, resp);
  }

  /**
   * Handle initial request from client and dispatch to appropriate handler or return error message.
   */
  @Override
  public void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    try {
      addCorsHeaders(resp);
      // Subset of path managed by this servlet; e.g. if URI is "/nigori/get" and servlet path
      // is "/nigori, then we want to retrieve "get" as the request type
      int startIndex = req.getServletPath().length() + 1;
      String requestURI = req.getRequestURI();
      if (requestURI.length() <= startIndex) {
        ServletException s =
            new ServletException(HttpServletResponse.SC_BAD_REQUEST, "No request type specified.\n"
                + supportedTypes + "\n");
        log.fine(s.toString());
        s.writeHttpResponse(resp);
        return;
      }
      String requestType = requestURI.substring(startIndex);
      String requestMimetype = req.getContentType();
      RequestHandlerType handlerType = new RequestHandlerType(requestMimetype, requestType);

      RequestHandler handler = handlers.get(handlerType);
      if (handler == null) {
        throw new ServletException(HttpServletResponse.SC_NOT_ACCEPTABLE,
            "Unsupported request pair: " + handlerType + "\n" + supportedTypes + "\n");
      }
      try {
        handler.handle(req, resp);
      } catch (NotFoundException e) {
        ServletException s =
            new ServletException(HttpServletResponse.SC_NOT_FOUND, e.getLocalizedMessage());
        log.fine(s.toString());
        s.writeHttpResponse(resp);
      } catch (UnauthorisedException e) {
        ServletException s =
            new ServletException(HttpServletResponse.SC_UNAUTHORIZED, "Authorisation failed: "
                + e.getLocalizedMessage());
        log.warning(s.toString());
        s.writeHttpResponse(resp);
      } catch (IOException ioe) {
        throw new ServletException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
            "Internal error sending data to client");
      } catch (MessageLibrary.JsonConversionException jce) {
        throw new ServletException(HttpServletResponse.SC_BAD_REQUEST, "JSON format error: "
            + jce.getMessage());
      } catch (RuntimeException re) {
        log.severe(re.toString());
        throw new ServletException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, re.toString());
      }

    } catch (ServletException e) {
      log.severe(e.toString());
      e.writeHttpResponse(resp);
    }
  }
}